Logo FSharp.Analyzers.SDK

Writing an analyzer for both console and editor

With a little orchestration it is possible to easily write two analyzer functions that share a common implementation.

open FSharp.Analyzers.SDK
open FSharp.Analyzers.SDK.ASTCollecting
open FSharp.Compiler.CodeAnalysis
open FSharp.Compiler.Text
open FSharp.Compiler.Syntax

/// This analyzer function will try and detect if any `System.*` open statement was found after any non System open.
/// See https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions#sort-open-statements-topologically
/// Note that this implementation is not complete and only serves as an illustration.
/// Nested modules are not taken into account.
let private topologicallySortedOpenStatementsAnalyzer
    (sourceText: ISourceText)
    (untypedTree: ParsedInput)
    (checkResults: FSharpCheckFileResults)
    : Async<Message list>
    =
    async {
        let allOpenStatements =
            let allOpenStatements = ResizeArray<string list * range>()

            let (|LongIdentAsString|) (lid: SynLongIdent) =
                lid.LongIdent
                |> List.map (fun ident -> ident.idText)

            let walker =
                { new SyntaxCollectorBase() with
                    override _.WalkSynModuleSigDecl(_, decl: SynModuleSigDecl) =
                        match decl with
                        | SynModuleSigDecl.Open(
                            target = SynOpenDeclTarget.ModuleOrNamespace(
                                longId = LongIdentAsString value; range = mOpen)) ->
                            allOpenStatements.Add(value, mOpen)
                        | _ -> ()

                    override _.WalkSynModuleDecl(_, decl: SynModuleDecl) =
                        match decl with
                        | SynModuleDecl.Open(
                            target = SynOpenDeclTarget.ModuleOrNamespace(
                                longId = LongIdentAsString value; range = mOpen)) ->
                            allOpenStatements.Add(value, mOpen)
                        | _ -> ()
                }

            ASTCollecting.walkAst walker untypedTree

            allOpenStatements
            |> Seq.toList

        let isSystemOpenStatement (openStatement: string list, mOpen: range) =
            let isFromBCL () =
                let line = sourceText.GetLineString(mOpen.EndLine - 1)

                match
                    checkResults.GetSymbolUseAtLocation(
                        mOpen.EndLine,
                        mOpen.EndColumn,
                        line,
                        openStatement
                    )
                with
                | Some symbolUse ->
                    match symbolUse.Symbol.Assembly.FileName with
                    | None -> false
                    | Some assemblyPath ->
                        // This might not be an airtight check
                        assemblyPath.ToLower().Contains "microsoft.netcore.app.ref"
                | _ -> false

            openStatement.[0].StartsWith("System")
            && isFromBCL ()

        let nonSystemOpens =
            allOpenStatements
            |> List.skipWhile isSystemOpenStatement

        return
            nonSystemOpens
            |> List.filter isSystemOpenStatement
            |> List.map (fun (openStatement, mOpen) ->
                let openStatementText =
                    openStatement
                    |> String.concat "."

                {
                    Type = "Unsorted System open statement"
                    Message =
                        $"%s{openStatementText} was found after non System namespaces where opened!"
                    Code = "SOT001"
                    Severity = Severity.Warning
                    Range = mOpen
                    Fixes = []
                }
            )
    }

[<CliAnalyzer "Topologically sorted open statements">]
let cliAnalyzer (ctx: CliContext) : Async<Message list> =
    topologicallySortedOpenStatementsAnalyzer
        ctx.SourceText
        ctx.ParseFileResults.ParseTree
        ctx.CheckFileResults

[<EditorAnalyzer "Topologically sorted open statements">]
let editorAnalyzer (ctx: EditorContext) : Async<Message list> =
    match ctx.CheckFileResults with
    // The editor might not have any check results for a given file. So we don't return any messages.
    | None -> async.Return []
    | Some checkResults ->
        topologicallySortedOpenStatementsAnalyzer
            ctx.SourceText
            ctx.ParseFileResults.ParseTree
            checkResults

Both analyzers will follow the same code path: the console application will always have the required data, while the editor needs to be more careful.
⚠️ Please do not be tempted by calling .Value on the EditorContext 😉.

To enable a wide range of analyzers, both context types give access to very detailed information about the source code.
Among this information is the full untyped abstract syntax tree (AST) and the typed abstract syntax tree (TAST). As you can deduce from the example above, processing these trees is a very common task in an analyzer. But writing your own tree traversal code can be daunting and can also get quite repetitive over many analyzers.
That's why the SDK offers the ASTCollecting and TASTCollecting modules. In there, you'll find facility types and functions to make your analyzers author life easier. For both trees, a type is defined, SyntaxCollectorBase and TypedTreeCollectorBase respectively, with members you can override to have easy access to the tree elements you want to process.
Just pass an instance with your overriden members to the walkAst or walkTast function.

The open-statement analyzer from above uses the AST for it's analysis.
Because we want to process the SynModuleSigDecl and SynModuleDecl elements of the AST, we just override the two appropriate members of the SyntaxCollectorBase type in an object expression and pass the instance to walkAst.
Much simpler and shorter than doing the traversal ourselves.

Previous Next

Multiple items
namespace FSharp

--------------------
namespace Microsoft.FSharp
namespace FSharp.Analyzers
namespace FSharp.Analyzers.SDK
module ASTCollecting
from FSharp.Analyzers.SDK
namespace FSharp.Compiler
namespace FSharp.Compiler.CodeAnalysis
namespace FSharp.Compiler.Text
namespace FSharp.Compiler.Syntax
This analyzer function will try and detect if any `System.*` open statement was found after any non System open.
See https://learn.microsoft.com/en-us/dotnet/fsharp/style-guide/conventions#sort-open-statements-topologically
Note that this implementation is not complete and only serves as an illustration.
Nested modules are not taken into account.

val private topologicallySortedOpenStatementsAnalyzer:
    sourceText: ISourceText ->
    untypedTree: ParsedInput ->
    checkResults: FSharpCheckFileResults ->
        Async<Message list>
val sourceText: ISourceText
type ISourceText =
  abstract ContentEquals: sourceText: ISourceText -> bool
  abstract CopyTo:
      sourceIndex: int *
      destination: char array *
      destinationIndex: int *
      count: int ->
          unit
  abstract GetLastCharacterPosition: unit -> int * int
  abstract GetLineCount: unit -> int
  abstract GetLineString: lineIndex: int -> string
  abstract GetSubTextFromRange: range: range -> string
  abstract GetSubTextString: start: int * length: int -> string
  abstract SubTextEquals: target: string * startIndex: int -> bool
  abstract Item: index: int -> char with get
  abstract Length: int
val untypedTree: ParsedInput
Multiple items
module ParsedInput
from FSharp.Compiler.Syntax

--------------------
type ParsedInput =
  | ImplFile of ParsedImplFileInput
  | SigFile of ParsedSigFileInput
  member FileName: string
  member Identifiers: Set<string>
  member QualifiedName: QualifiedNameOfFile
  member Range: range
val checkResults: FSharpCheckFileResults
type FSharpCheckFileResults =
  member GenerateSignature: ?pageWidth: int -> ISourceText option
  member GetAllUsesOfAllSymbolsInFile:
      ?cancellationToken: CancellationToken ->
          FSharpSymbolUse seq
  member GetDeclarationListInfo:
      parsedFileResults: FSharpParseFileResults option *
      line: int *
      lineText: string *
      partialName: PartialLongName *
      ?getAllEntities: (unit -> AssemblySymbol list) *
      ?completionContextAtPos: (pos * CompletionContext option) *
      ?options: FSharpCodeCompletionOptions ->
          DeclarationListInfo
  member GetDeclarationListSymbols:
      parsedFileResults: FSharpParseFileResults option *
      line: int *
      lineText: string *
      partialName: PartialLongName *
      ?getAllEntities: (unit -> AssemblySymbol list) *
      ?options: FSharpCodeCompletionOptions ->
          FSharpSymbolUse list list
  member GetDeclarationLocation:
      line: int *
      colAtEndOfNames: int *
      lineText: string *
      names: string list *
      ?preferFlag: bool ->
          FindDeclResult
  member GetDescription:
      symbol: FSharpSymbol *
      inst: (FSharpGenericParameter * FSharpType) list *
      displayFullName: bool *
      range: range ->
          ToolTipText
  member GetDisplayContextForPos: cursorPos: pos -> FSharpDisplayContext option
  member GetF1Keyword:
      line: int *
      colAtEndOfNames: int *
      lineText: string *
      names: string list ->
          string option
  member GetFormatSpecifierLocationsAndArity: unit -> (range * int) array
  member GetKeywordTooltip: names: string list -> ToolTipText
  ...
Multiple items
type Async =
  static member AsBeginEnd:
      computation: ('Arg -> Async<'T>) ->
          ('Arg * AsyncCallback * objnull -> IAsyncResult) * (IAsyncResult -> 'T) * (IAsyncResult -> unit)
  static member AwaitEvent: event: IEvent<'Del,'T> * ?cancelAction: (unit -> unit) -> Async<'T> (requires delegate and 'Del :> Delegate and 'Del: not null)
  static member AwaitIAsyncResult:
      iar: IAsyncResult *
      ?millisecondsTimeout: int ->
          Async<bool>
  static member AwaitTask: task: Task<'T> -> Async<'T> + 1 overload
  static member AwaitWaitHandle:
      waitHandle: WaitHandle *
      ?millisecondsTimeout: int ->
          Async<bool>
  static member CancelDefaultToken: unit -> unit
  static member Catch: computation: Async<'T> -> Async<Choice<'T,exn>>
  static member Choice: computations: Async<'T option> seq -> Async<'T option>
  static member FromBeginEnd: beginAction: (AsyncCallback * objnull -> IAsyncResult) * endAction: (IAsyncResult -> 'T) * ?cancelAction: (unit -> unit) -> Async<'T> + 3 overloads
  static member FromContinuations:
      callback: (('T -> unit) * (exn -> unit) * (OperationCanceledException -> unit) -> unit) ->
          Async<'T>
  ...

--------------------
type Async<'T>
type Message =
  {
    Type: string
    Message: string
    Code: string
    Severity: Severity
    Range: range
    Fixes: Fix list
  }
type 'T list = List<'T>
val async: AsyncBuilder
val allOpenStatements: (string list * range) list
val allOpenStatements: ResizeArray<string list * range>
type ResizeArray<'T> = System.Collections.Generic.List<'T>
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type range = Range
val lid: SynLongIdent
Multiple items
union case SynLongIdent.SynLongIdent:
    id: LongIdent *
    dotRanges: range list *
    trivia: FSharp.Compiler.SyntaxTrivia.IdentTrivia option list ->
        SynLongIdent

--------------------
type SynLongIdent =
  | SynLongIdent of id:
      LongIdent *
      dotRanges: range list *
      trivia: IdentTrivia option list
  member Dots: range list
  member IdentsWithTrivia: SynIdent list
  member LongIdent: LongIdent
  member Range: range
  member RangeWithoutAnyExtraDot: range
  member ThereIsAnExtraDotAtTheEnd: bool
  member Trivia: IdentTrivia list
property SynLongIdent.LongIdent: LongIdent with get
Multiple items
module List
from Microsoft.FSharp.Collections

--------------------
type List<'T> =
  | op_Nil
  | op_ColonColon of Head: 'T * Tail: 'T list
  interface IReadOnlyList<'T>
  interface IReadOnlyCollection<'T>
  interface IEnumerable
  interface IEnumerable<'T>
  member GetReverseIndex: rank: int * offset: int -> int
  member GetSlice: startIndex: int option * endIndex: int option -> 'T list
  static member Cons: head: 'T * tail: 'T list -> 'T list
  member Head: 'T
  member IsEmpty: bool
  member Item: index: int -> 'T with get
  ...
val map: mapping: ('T -> 'U) -> list: 'T list -> 'U list
val ident: Ident
property Ident.idText: string with get
val walker: SyntaxCollectorBase
Multiple items
The members of this type are called by walkAst.
By overwriting the members for various syntax elements, a custom operation can be executed for them.

type SyntaxCollectorBase =
  new: unit -> SyntaxCollectorBase
  abstract WalkAttribute: path: SyntaxVisitorPath * attribute: SynAttribute -> unit + 1 overload
  abstract WalkBinding: path: SyntaxVisitorPath * binding: SynBinding -> unit + 1 overload
  abstract WalkClause: path: SyntaxVisitorPath * matchClause: SynMatchClause -> unit + 1 overload
  abstract WalkComponentInfo: path: SyntaxVisitorPath * componentInfo: SynComponentInfo -> unit + 1 overload
  abstract WalkEnumCase: path: SyntaxVisitorPath * enumCase: SynEnumCase -> unit + 1 overload
  abstract WalkExpr: path: SyntaxVisitorPath * expr: SynExpr -> unit + 1 overload
  abstract WalkField: path: SyntaxVisitorPath * field: SynField -> unit + 1 overload
  abstract WalkInterfaceImpl: path: SyntaxVisitorPath * interfaceImpl: SynInterfaceImpl -> unit + 1 overload
  abstract WalkInterpolatedStringPart: path: SyntaxVisitorPath * interpolatedStringPart: SynInterpolatedStringPart -> unit + 1 overload
  ...

--------------------
new: unit -> SyntaxCollectorBase
val decl: SynModuleSigDecl
type SynModuleSigDecl =
  | ModuleAbbrev of ident: Ident * longId: LongIdent * range: range
  | NestedModule of moduleInfo:
      SynComponentInfo *
      isRecursive: bool *
      moduleDecls: SynModuleSigDecl list *
      range: range *
      trivia: SynModuleSigDeclNestedModuleTrivia
  | Val of valSig: SynValSig * range: range
  | Types of types: SynTypeDefnSig list * range: range
  | Exception of exnSig: SynExceptionSig * range: range
  | Open of target: SynOpenDeclTarget * range: range
  | HashDirective of hashDirective: ParsedHashDirective * range: range
  | NamespaceFragment of SynModuleOrNamespaceSig
  member Range: range
union case SynModuleSigDecl.Open:
    target: SynOpenDeclTarget *
    range: range ->
        SynModuleSigDecl
type SynOpenDeclTarget =
  | ModuleOrNamespace of longId: SynLongIdent * range: range
  | Type of typeName: SynType * range: range
  member Range: range
union case SynOpenDeclTarget.ModuleOrNamespace:
    longId: SynLongIdent *
    range: range ->
        SynOpenDeclTarget
active recognizer LongIdentAsString: SynLongIdent -> string list
val value: string list
val mOpen: range
System.Collections.Generic.List.Add(item: string list * range) : unit
val decl: SynModuleDecl
type SynModuleDecl =
  | ModuleAbbrev of ident: Ident * longId: LongIdent * range: range
  | NestedModule of moduleInfo:
      SynComponentInfo *
      isRecursive: bool *
      decls: SynModuleDecl list *
      isContinuing: bool *
      range: range *
      trivia: SynModuleDeclNestedModuleTrivia
  | Let of isRecursive:
      bool *
      bindings: SynBinding list *
      range: range *
      trivia: SynModuleDeclLetTrivia
  | Expr of expr: SynExpr * range: range
  | Types of typeDefns: SynTypeDefn list * range: range
  | Exception of exnDefn: SynExceptionDefn * range: range
  | Open of target: SynOpenDeclTarget * range: range
  | Attributes of attributes: SynAttributes * range: range
  | HashDirective of hashDirective: ParsedHashDirective * range: range
  | NamespaceFragment of fragment: SynModuleOrNamespace
  member Range: range
union case SynModuleDecl.Open:
    target: SynOpenDeclTarget *
    range: range ->
        SynModuleDecl
Traverses the whole AST and calls the appropriate members of the given SyntaxCollectorBase
to process the syntax elements.

val walkAst: walker: SyntaxCollectorBase -> input: ParsedInput -> unit
module Seq
from Microsoft.FSharp.Collections
val toList: source: 'T seq -> 'T list
val isSystemOpenStatement: openStatement: string list * mOpen: range -> bool
val openStatement: string list
val isFromBCL: unit -> bool
val line: string
abstract ISourceText.GetLineString: lineIndex: int -> string
property Range.EndLine: int with get
member FSharpCheckFileResults.GetSymbolUseAtLocation:
    line: int *
    colAtEndOfNames: int *
    lineText: string *
    names: string list ->
        FSharpSymbolUse option
property Range.EndColumn: int with get
union case Option.Some: Value: 'T -> Option<'T>
val symbolUse: FSharpSymbolUse
property FSharpSymbolUse.Symbol: FSharp.Compiler.Symbols.FSharpSymbol with get
property FSharp.Compiler.Symbols.FSharpSymbol.Assembly: FSharp.Compiler.Symbols.FSharpAssembly with get
property FSharp.Compiler.Symbols.FSharpAssembly.FileName: string option with get
union case Option.None: Option<'T>
val assemblyPath: string
System.String.ToLower() : string
System.String.ToLower(culture: System.Globalization.CultureInfo) : string
val nonSystemOpens: (string list * range) list
val skipWhile: predicate: ('T -> bool) -> list: 'T list -> 'T list
val filter: predicate: ('T -> bool) -> list: 'T list -> 'T list
val openStatementText: string
module String
from Microsoft.FSharp.Core
val concat: sep: string -> strings: string seq -> string
type Severity =
  | Info
  | Hint
  | Warning
  | Error
union case Severity.Warning: Severity
Multiple items
module Range
from FSharp.Compiler.Text

--------------------
type Range =
  member ApplyLineDirectives: unit -> range
  member End: pos
  member EndColumn: int
  member EndLine: int
  member EndRange: range
  member FileName: string
  member IsSynthetic: bool
  member Start: pos
  member StartColumn: int
  member StartLine: int
  ...
Multiple items
Marks an analyzer for scanning during the console application run.
type CliAnalyzerAttribute =
  inherit AnalyzerAttribute
  new:
      [<Optional; DefaultParameterValue (("Analyzer" :> obj))>] name: string *
      [<Optional; DefaultParameterValue (("" :> obj))>] shortDescription: string *
      [<Optional; DefaultParameterValue (("" :> obj))>] helpUri: string ->
          CliAnalyzerAttribute
  member Name: string

--------------------
new:
    [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("Analyzer" :> obj))>] name: string *
    [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] shortDescription: string *
    [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] helpUri: string ->
        CliAnalyzerAttribute
val cliAnalyzer: ctx: CliContext -> Async<Message list>
val ctx: CliContext
All the relevant compiler information for a given file.
Contains the source text, untyped and typed tree information.

type CliContext =
  {
    FileName: string
    SourceText: ISourceText
    ParseFileResults: FSharpParseFileResults
    CheckFileResults: FSharpCheckFileResults
    TypedTree: FSharpImplementationFileContents option
    CheckProjectResults: FSharpCheckProjectResults
    ProjectOptions: AnalyzerProjectOptions
    AnalyzerIgnoreRanges: Map<string,AnalyzerIgnoreRange list>
  }
  interface Context
  member GetAllEntities: publicOnly: bool -> AssemblySymbol list
  member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq
  member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
Source of the current file.
See ISourceText Type

CliContext.SourceText: ISourceText
Represents the results of parsing an F# file and a set of analysis operations based on the parse tree alone.
See FSharpParseFileResults Type

CliContext.ParseFileResults: FSharpParseFileResults
property FSharpParseFileResults.ParseTree: ParsedInput with get
A handle to the results of CheckFileInProject.
See FSharpCheckFileResults Type

CliContext.CheckFileResults: FSharpCheckFileResults
Multiple items
Marks an analyzer for scanning during IDE integration.
type EditorAnalyzerAttribute =
  inherit AnalyzerAttribute
  new:
      [<Optional; DefaultParameterValue (("Analyzer" :> obj))>] name: string *
      [<Optional; DefaultParameterValue (("" :> obj))>] shortDescription: string *
      [<Optional; DefaultParameterValue (("" :> obj))>] helpUri: string ->
          EditorAnalyzerAttribute
  member Name: string

--------------------
new:
    [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("Analyzer" :> obj))>] name: string *
    [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] shortDescription: string *
    [<System.Runtime.InteropServices.Optional; System.Runtime.InteropServices.DefaultParameterValue (("" :> obj))>] helpUri: string ->
        EditorAnalyzerAttribute
val editorAnalyzer: ctx: EditorContext -> Async<Message list>
val ctx: EditorContext
Optional compiler information for a given file.
The available contents is controlled based on what information the IDE has available.

type EditorContext =
  {
    FileName: string
    SourceText: ISourceText
    ParseFileResults: FSharpParseFileResults
    CheckFileResults: FSharpCheckFileResults option
    TypedTree: FSharpImplementationFileContents option
    CheckProjectResults: FSharpCheckProjectResults option
    ProjectOptions: AnalyzerProjectOptions
    AnalyzerIgnoreRanges: Map<string,AnalyzerIgnoreRange list>
  }
  interface Context
  member GetAllEntities: publicOnly: bool -> AssemblySymbol list
  member GetAllSymbolUsesOfFile: unit -> FSharpSymbolUse seq
  member GetAllSymbolUsesOfProject: unit -> FSharpSymbolUse array
A handle to the results of CheckFileInProject.
See FSharpCheckFileResults Type

EditorContext.CheckFileResults: FSharpCheckFileResults option
member AsyncBuilder.Return: value: 'T -> Async<'T>
Source of the current file.
See ISourceText Type

EditorContext.SourceText: ISourceText
Represents the results of parsing an F# file and a set of analysis operations based on the parse tree alone.
See FSharpParseFileResults Type

EditorContext.ParseFileResults: FSharpParseFileResults

Type something to start searching.